Skip to content

fix(mcp): fold parent snapshot id into forget/compile cursor hashes - #246

Merged
radimsem merged 1 commit into
devfrom
fix/forget-compile-cursor-hash
Jun 4, 2026
Merged

fix(mcp): fold parent snapshot id into forget/compile cursor hashes#246
radimsem merged 1 commit into
devfrom
fix/forget-compile-cursor-hash

Conversation

@radimsem

@radimsem radimsem commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Closes #244.

Problem

snapshots.cursor_hash is CHAR(16) NOT NULL UNIQUE. #238 fixed MemoryWrite/MemorySummarize by folding the monotonic parent snapshot id into the cursor hash. Two snapshot-emitting paths were left on content/delta-only digests that do not fold the parent id, so the same collision class still applied:

  • MemoryCompile used diff.CursorHashFlat(flat) (pure content digest) — an edit→compile→revert→compile that lands the post-state on a prior content state reproduces the earlier hash.
  • MemoryForget used diff.CursorHashForDeltas(deltas) (op + id + old/new hashes, no parent id) — a re-add-then-forget reproducing an identical delta set collides.

Either case fails with the opaque UNIQUE constraint failed: snapshots.cursor_hash and a full Tx rollback.

Fix

Route both through parent-folded hashing, the model #238 adopted:

  • MemoryForget now reads GetHeadSnapshotID (under OpMu) and uses diff.CursorHashForChange(prevHeadID, deltas).
  • MemoryCompile now reads GetHeadSnapshotIDTx inside its existing Store.Tx, before EmitTx (which advances HEAD), and uses the new diff.CursorHashForCompile(prevHeadID, flat). Still one EmitTx in one Tx — §7 one-snapshot-per-call preserved.
  • DRY (flagged in [bug] MemoryWrite hits UNIQUE cursor_hash on toggle: write A → write B → write A trips snapshots.cursor_hash #238 review): extracted the shared writeID / writeFlatPairs / writeDeltaPairs / sumHex helpers; CursorHashForChange / CursorHashForRollback / CursorHashFlat are byte-identical to before. Removed the now-unused CursorHashForDeltas.

Tests

  • pkg/diff: TestCursorHashForCompile_DistinctAcrossRepeatState, _OrderIndependent
  • pkg/mcp/tools: TestHandleForget_ReAddThenForget (write+forget ×2 → 4 distinct snapshots)
  • pkg/compiler: TestCompile_RevertToPriorStateSucceeds (A→B→A → 3 distinct snapshots)

Each regression test was confirmed to fail with UNIQUE constraint failed: snapshots.cursor_hash against the pre-fix hashing and pass after. Full suite green: build, go vet, 1185 tests, golangci-lint, make check-skills.

Reviewers

go-style, mcp-surface, generic-correctness, and Codex reviews all clean.

Note

Docs (docs/versioning.md, skills/remind/references/snapshots-diffs.md) already describe cursor_hash as an opaque, unique-per-snapshot fingerprint (corrected in #238), so no doc change is needed.

A follow-up: pkg/mcp/rescan/rescan.go purge emit is still on content-only CursorHashFlat — same latent class, outside this issue's scope; will file separately.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@radimsem
radimsem merged commit 04b33ea into dev Jun 4, 2026
5 checks passed
radimsem added a commit that referenced this pull request Jun 6, 2026
Closes #247.

## Problem

The background rescan purge path (`pkg/mcp/rescan/rescan.go`
`reconcileDeleted`) emitted its deleted-file snapshot with a
**content-only** `diff.CursorHashFlat(synthetic)`.
`snapshots.cursor_hash` is `CHAR(16) NOT NULL UNIQUE`, so a sequence
that purges the *same* file set twice with an identical recreate in
between (delete → purge → recreate+compile → delete same files → purge)
reproduces the earlier purge hash and collides. The failure is swallowed
(`r.logger.Error` + `return false`), so the purge snapshot is silently
dropped rather than surfaced.

This is the last `emitter.Emit*` call site that was still on a
parent-unaware digest — follow-up to #238 (write/summarize) and
#244/#246 (compile/forget).

## Fix

Route the purge through parent-folded hashing, the model #238 adopted:

- Read `prevHeadID` via `GetHeadSnapshotID` — already inside the `OpMu`
critical section, so the head-id read and the `emitter.Emit` (which
advances HEAD) stay atomic.
- Swap `diff.CursorHashFlat(synthetic)` →
`diff.CursorHashForChange(prevHeadID, deltas)`.
- Drop the now-unused `synthetic []*parser.ContextNode` scaffold (it
existed only to feed `CursorHashFlat`) and the orphaned `parser` import.

## Tests

- `pkg/mcp/rescan`: `TestRescanLoop_RepeatedDeleteOfSameFileSet` —
delete the same file set twice with an identical recreate in between;
asserts two distinct purge snapshots. Confirmed to fail pre-fix (`purge
snapshots = 1, want 2` — second purge swallowed by the `UNIQUE`
collision) and pass after.
- Full suite green: build, `go vet`, **1186 tests**, gofmt,
golangci-lint (0 issues).

## Audit (closes the #247 follow-up note)

All five `emitter.Emit*` snapshot sites now fold the parent id:
`compiler`→`CursorHashForCompile`, `rollback`→`CursorHashForRollback`,
write/summarize+`forget`+`rescan`→`CursorHashForChange`.
`CursorHashFlat`/`CursorHash` remain only for non-snapshot,
diff-internal uses. No further emit path on a bare content/delta hash.

## Reviewers

go-style and Codex reviews both clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] MemoryCompile/MemoryForget can still hit UNIQUE cursor_hash on content revert

1 participant